Current Sprint: 3. 遊戲基本流程完成
repo: https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan
swagger docs: https://side-project-at-spt.github.io/ithome-ironman-2024-san-juan/
rounds
,說明目前是第幾回合steps
,說明目前是第幾步預估還有一些時間(?,嘗試把前端的畫面完成一下,大家應該比較容易看出遊戲有什麼功能 XD
phase
,用來描述目前是哪個職業階段礦工
階段行動step
model 用來儲存遊戲(每一步)紀錄議員
階段行動建築
階段行動生產
階段行動交易
階段行動我們採用 ActionCable 來實現 WebSocket 的功能
config/cable.yml
development:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: ithome_ironman_2024_san_juan_development
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: ithome_ironman_2024_san_juan_production
之前的做法是寫死 1 human + 3 bots
我們讓 Game.start_new_game
及其中的 generate_players
增加 players
參數,讓我們可以把 玩家 ID 放進去產生遊戲
# app/models/game.rb
class Game < ApplicationRecord
# ...
class << self
# ...
def start_new_game(seed: nil, game: nil, players: nil)
# ...
end
def generate_players(seed: nil, players: nil)
srand(seed.to_i(16)) if seed
if players
human_players = players.map { |player| Player.new(player, [], [], nil, false) }
bot_players = (4 - players.size).times.map { |i| Player.new("bot_#{i + 1}", [], [], nil, true) }
else
human_players = [ Player.new(1, [], [], nil, false) ]
bot_players = 3.times.map { |i| Player.new(i + 2, [], [], nil, true) }
end
(human_players + bot_players).shuffle
end
# ...
end
# ...
end
# ...
當前端請求建立 WebSocket 連線時,會先由 connection.rb
辨認並註記 identified_by :current_user
這個 current_user
可以在底下所有的 channel 中取用,如此達到識別身份的目的
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if verified_user = MockUser.find_by(id: JWT.decode(request.params[:token], Rails.application.secret_key_base).first["sub"])
verified_user
else
reject_unauthorized_connection
end
end
end
end
目前還沒有建立使用者 model,先定義 MockUser
展示用
# app/models/mock_user.rb
class MockUser
class << self
def find_by(id:)
return new("visitor") unless id
new(id)
end
end
def initialize(id)
@id = id
end
def id
@id
end
def email
"mock_user_#{@id}@localhost"
end
end
新增登入的 api
# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
def create
id = Time.now.to_i
token = JWT.encode({ sub: id }, Rails.application.secret_key_base)
render json: { status: :created, token: token }
end
end
# config/routes.rb
Rails.application.routes.draw do
# ...
namespace :api do
namespace :v1 do
post "login" => "sessions#create", as: :login
# ...
end
end
end
end
這裡因為採用前後端不同 host,WebSocket 沒辦法以 cookie 方式驗證,於是簽發 JWT 作為驗證身份手段
再來是 channel
當成功建立連線後,使用者可以訂閱頻道,當有訊息發到頻道時,便能收到通知
也可以呼叫頻道定義的方法,去跟後端互動
我們建立一個 Lobby
的頻道,發送供所有人觀看訊息的地方
可以作為發送訊息
或是一般性的互動
chat
room_list
room_info
rais g channel lobby
第一次寫把 房間的邏輯 也混進去了
有關房間的
從 room_channel
操作會比較適合
而遊戲的訊息推送,可以讓玩家訂閱 game_channel(id, player)
來發送手牌的個人訊息
而從 game_channel(id)
發送公開訊息 (或是旁觀者)
房間的房名、房主、成員等資訊,就紀錄在 redis
# app/channels/lobby_channel.rb
class LobbyChannel < ApplicationCable::Channel
def subscribed
stream_from "lobby_channel"
data = { message: "Hello, #{current_user.email}!" }
ActionCable.server.broadcast "lobby_channel", data
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def speak(data)
ActionCable.server.broadcast "lobby_channel", message: data
end
def create_room
room = Kredis.string "room:#{SecureRandom.hex(4)}"
room.value = "Room #{room.key}"
room_owner = Kredis.string "#{room.key}:owner"
room_owner.value = current_user.email
room_participants = Kredis.set "#{room.key}:participants"
room_participants.add current_user.email
message = { message: "#{room.value} created!" }
ActionCable.server.broadcast "lobby_channel", message
end
def get_rooms
rooms = $redis.scan_each(match: "room:*").map do |room|
room_name = room.split(":")[1]
room_name
end
message = { rooms: rooms.uniq }
ActionCable.server.broadcast "lobby_channel", message
end
def get_participant_rooms
# Get all rooms where the current user is a participant
rooms = $redis.scan_each(match: "room:*:participants").map do |room|
if $redis.smembers(room).include? current_user.email
room_name = room.split(":")[1]
room_name
end
end
rooms.compact!
message = { rooms: rooms.uniq }
ActionCable.server.broadcast "lobby_channel", message
end
def leave_room(params)
action = params["action"]
room_key = params["room"]
# can not leave room if you are not in the room
room_participants = Kredis.set "room:#{room_key}:participants"
if room_participants.include? current_user.email
# close room if you are the owner
room_owner = Kredis.string "room:#{room_key}:owner"
if room_owner.value == current_user.email
room = Kredis.string "room:#{room_key}"
room_owner = Kredis.string "room:#{room_key}:owner"
room_participants.clear
room_owner.clear
room.clear
# Kredis.del "room:#{room_key}"
# Kredis.del "room:#{room_key}:owner"
# Kredis.del "room:#{room_key}:participants"
message = { message: "Room #{room_key} closed!" }
ActionCable.server.broadcast "lobby_channel", message
else
room_participants.remove current_user.email
message = { message: "You have left room #{room_key}" }
ActionCable.server.broadcast "lobby_channel", message
end
else
message = { message: "You are not in room #{room_key}" }
ActionCable.server.broadcast "lobby_channel", message
end
end
def clear_rooms
count = 0
$redis.scan_each(match: "room:*").each do |room|
$redis.del room
count += 1
end
message = { message: "#{count} rooms cleared!" }
ActionCable.server.broadcast "lobby_channel", message
end
def join_room(params)
room_key = params["room"]
room_participants = Kredis.set "room:#{room_key}:participants"
room_participants.add current_user.email
message = { message: "You have joined room #{room_key}" }
ActionCable.server.broadcast "lobby_channel", message
end
def show_room_info(params)
room_key = params["room"]
room = Kredis.string "room:#{room_key}"
room_owner = Kredis.string "room:#{room_key}:owner"
room_participants = Kredis.set "room:#{room_key}:participants"
message = {
message: "Room #{room.value} info: owner - #{room_owner.value}, participants - #{room_participants.members.to_sentence}",
owner: room_owner.value,
participants: room_participants.members
}
ActionCable.server.broadcast "lobby_channel", message
end
def start_new_game(params)
room_key = params["room"]
room_owner = Kredis.string "room:#{room_key}:owner"
case room_owner.value
when nil
# room does not exist
message = { message: "Room #{room_key} does not exist" }
ActionCable.server.broadcast "lobby_channel", message
when current_user.email
# room owner
room_participants = Kredis.set "room:#{room_key}:participants"
game = Game.start_new_game(players: room_participants.members)
message = { message: "Game started in room #{room_key}", game_id: game.id }
ActionCable.server.broadcast "lobby_channel", message
else
message = { message: "Only the room owner can start the game" }
ActionCable.server.broadcast "lobby_channel", message
end
end
end
如果是透過 zeabur (https://zeabur.com/templates/KQZHXT) 部署的話,可從服務市集選取 redis 即可
並注意
config/environments/production.rb
config.action_cable.allowed_request_origins
REDIS_URL
=> ${REDIS_URI}
請參考 https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan-frontend-example
過兩天應該會用 vue 改寫
收工.
以上不代表明天會做,如有雷同純屬巧合
SPT (Side Project Taiwan) 的宗旨是藉由Side Project開發來成就自我,透過持續學習和合作,共同推動技術和專業的發展。我們相信每一個參與者,無論是什麼專業,都能在這個社群中找到屬於自己的成長空間。
歡迎所有對Side Project開發有興趣的人加入我們,可以是有點子來找夥伴,也可以是來尋找有興趣的Side Project加入,邀請大家一同打造一個充滿活力且有意義的技術社群!
Discord頻道連結: https://sideproj.tw/dc